﻿/// <reference path="AutoComplete.js" />
/// <reference path="AutoCompleteEntry.js" />
/// <reference path="AutoCompleteEntries.js" />
/// <reference path="../../utilities/Markup.js" />
/// <reference path="../../utilities/Xpcom.js" />
///--------------------------------------------------------------------
/// <file name="AutoCompleteController.js">
///   The controller for the AutoComplete UI.
/// </file>
///--------------------------------------------------------------------

NAMFox.ns(function() { with (NAMFox) {

this.AutoCompleteController = function(htmlDocument) {
    /// <summary>
    ///   Initializes a new instance of the AutoCompleteController class,
    ///   targeted toward the specified document.
    /// </summary>
    /// <param name="htmlDocument" type="Document">
    ///   The document this AutoComplete will be added to.
    /// </param>
    
    this._htmlDocument = htmlDocument;
    this._entries = AutoCompleteEntries.allTags.slice();
};

AutoCompleteController.prototype = {
    _autoComplete: null,
    _contextStart: null,
    _lastBestIndex: 0,
    _surroundWith: null,
    _textArea: null,
    
    _findBestMatch: function(context, start, end) {
        /// <summary>
        ///   Finds the best AutoCompleteEntry match based
        ///   on the context and the mode.
        /// </summary>
        /// <param name="context" type="Object">
        ///   The context in which a choice is being made.
        ///   Requires two properties, previous and following. See
        ///   return type from _getContext.
        /// </param>
        /// <param name="start" type="Number" integer="true" optional="true">
        ///   (Optional) The index in the AutoCompleteEntries.allTags array
        ///   to start considering entries for matching. This is a performance
        ///   optimization because we can exclude a number of other entries from
        ///   consideration.
        /// </param>
        /// <param name="end" type="Number" integer="true" optional="true">
        ///   (Optional) The index in the AutoCompleteEntries.allTags array
        ///   to stop considering entries for matching. This is a performance
        ///   optimization because we can exclude a number of other entries from
        ///   consideration.
        /// </param>
        /// <returns type="Object">
        ///   - index: The index of the best match.
        ///   - isOnlyMatch: Specifies whether the best match
        ///       is the only possible match.
        ///   - itemState: The AutoCompleteItemState for the match.
        /// </returns>
        
        // No contextual text means not enough information to find a
        // best match. Just return the first item as a suggestion.
        if (!context.previous && !context.following) {
            return {
                index: 0,
                isOnlyMatch: false,
                itemState: AutoCompleteItemState.Suggest
            };
        }
        
        var entries = this._entries;
        entries.sort(
            function(x, y) {
                var xText = x.getTitle();
                var yText = y.getTitle();
                
                if (xText > yText) {
                    return 1;
                }
                else if (xText < yText) {
                    return -1;
                }
                
                return 0;
            }
        );
        for (var i = 0; i < entries.length; ++i) {
            entries[i].searchPosition = i;
        }
        
        if (!start || start < 0) {
            start = 0;
        }
        
        if (!end || end > entries.length) {
            end = entries.length - 1;
        }
        
        var bestIndex = -1;
        var bestCharacterMatchCount = 0;
        var lastBestIndex = 0;
        var itemState = AutoCompleteItemState.Select;
        
        $.verbose("AUTOCOMPLETE | Best Match Previous: '" + context.previous + "' | Following: '" + context.following + "'");
        context = context.previous + context.following;
        var inputIndex = 0;
        while (start <= end) {
            var middle = parseInt((start + end) / 2);
            
            var displayText = entries[middle].getTitle();
            var displayTextLastIndex = displayText.length - 1;
            
            $.verbose("AUTOCOMPLETE | Search: " + start + "-----" + middle + "(" + displayText + ")-----" + end);
            
            // We need to make sure our character comparisons are not out of
            // bounds, so here we evaluate which characters we need to compare.
            var indexUnderConsideration = Math.min(displayTextLastIndex, inputIndex);
            
            var displayTextCharacter = displayText[indexUnderConsideration];
            var contextCharacter = context[indexUnderConsideration];
            
            // Before we compare those characters, we need to ensure that all
            // previous characters match each other.
            var i = -1;
            var moveForward = false, moveBack = false;
            
            while (++i < indexUnderConsideration) {
                if (displayText[i] === context[i]) {
                    continue;
                }
                else if (displayText[i] > context[i]) {
                    moveBack = true;
                    break;
                }
                else {
                    moveForward = true;
                    break;
                }
            }
            
            $.verbose("AUTOCOMPLETE | Move Back: " + moveBack + " / Move Forward: " + moveForward);
            
            // The moveForward and moveBack variables give us an indication
            // of whether we are just right to continue the comparison, or
            // whether we need to change the entry we are considering.
            
            if (moveBack || displayTextCharacter > contextCharacter) {
                end = middle - 1;
            }
            else if (moveForward || contextCharacter > displayTextCharacter) {
                start = middle + 1;
            }
            else {
                if (indexUnderConsideration >= bestCharacterMatchCount) {
                    $.verbose("AUTOCOMPLETE | Best Match Update: " + bestIndex + " | Character Count: " + indexUnderConsideration);
                    bestCharacterMatchCount = indexUnderConsideration;
                    bestIndex = middle;
                }
                
                // Even if this is a best match, we still need to make sure
                // we consider all parts of the text. Otherwise we could mistake
                // the context "[im" for an italics tag if we considered only
                // "[i".
                if (displayTextLastIndex < inputIndex) {
                    start = middle + 1;
                    continue;
                }
                else if (inputIndex === context.length - 1) {
                    // This looks like a promising match, but it may not be the
                    // first one we can find in the AutoCompleteEntry list.
                    // We'll keep track of it just in case, but we need to re-run
                    // the search, moving back in the list.
                    end = middle - 1;
                    continue;
                }
                $.verbose("AUTOCOMPLETE | Next character");
                ++inputIndex;
            }
        }
        
        $.verbose("AUTOCOMPLETE | Best Index: " + bestIndex);
        var isOnlyMatch = false;
        if (bestIndex > -1) {
            if (entries[bestIndex].getTitle().substr(0, context.length) !== context) {
                $.verbose("AUTOCOMPLETE | Not a best index match: '" + entries[bestIndex].getTitle() + "' | '" + context + "'");
                itemState = AutoCompleteItemState.Suggest;
                bestIndex = this._lastBestIndex;
            }
            else {
                // Determine whether the bestIndex match is the only possible
                // match. If there is another entry after this one, compare it
                // with the context. If there is a mismatch, then the entry
                // at the bestIndex is the only possible match.
                if (entries.length > bestIndex + 1) {
                    var nextEntrySubstring = entries[bestIndex + 1].getTitle().substr(0, context.length);
                    
                    if (nextEntrySubstring !== context) {
                        isOnlyMatch = true;
                    }
                }
                else {
                    isOnlyMatch = true;
                }
                
                this._lastBestIndex = bestIndex;
            }
        }
        else {
            this._lastBestIndex = bestIndex = 0;
            itemState = AutoCompleteItemState.Suggest;
        }
        
        $.verbose("AUTOCOMPLETE | Index: " + bestIndex + " -> " + entries[bestIndex].uiPosition + " / Only Match: " + isOnlyMatch + " / State: " + itemState);
        
        return {
            index: entries[bestIndex].uiPosition,
            isOnlyMatch: isOnlyMatch,
            itemState: itemState
        };
    },
    
    _getContext: function(character) {
        /// <summary>
        ///   Gets the context of a key press--that is, the
        ///   text before the key press and the text after the
        ///   key press--for the purpose of determining the
        ///   best AutoCompleteEntry match.
        /// </summary>
        /// <param name="character" type="String" optional="true">
        ///   (Optional) The newest character that the user pressed
        ///   but has not yet been added to the text area.
        /// </param>
        /// <returns type="Object">
        ///   - previous: The previous contextual text.
        ///   - following: The following contextual text.
        /// </returns>
        
        if (!character) {
            character = "";
        }
        
        $.verbose("AUTOCOMPLETE | Context Start: " + (this._contextStart || "None"));
        
        var previous = "";
        if (this._contextStart !== null) {
            previous = this._textArea.value.substring(this._contextStart, this._textArea.selectionStart) + character;
        }
        else {
            var previousText = this._textArea.value.substring(0, this._textArea.selectionStart) + character;
            
            // Look for text that starts with one of the characters in the
            // character class and ends with non-whitespace text up to the caret.
            var previousMatch = previousText.match(/(?:^[\s\S]*[\s\.\]\(\"]|^)(\S*)$/);
        
            if (previousMatch) {
                previous = previousMatch[1];
                this._contextStart = this._textArea.selectionStart - previous.length + character.length;
            }
        }
        
        var followingText = this._textArea.value.substr(this._textArea.selectionEnd);
        
        // Look for text that starts with non-whitespace text from the caret
        // up until one of the characters in the character class.
        var followingMatch = followingText.match(/^(\S*?)(?:[\s\.\?\!\[\"\'\(\)]|$)/);
        
        var following = "";
        
        if (followingMatch) {
            following = followingMatch[1];
        }
        
        $.verbose("AUTOCOMPLETE | Previous: '" + previous + "' | Following: '" + following + "'");
        
        return {
            previous: previous,
            following: following
        };
    },
    
    _getPopupPoint: function(autoCompleteMode) {
        /// <summary>
        ///   Determines the (x, y) coordinates at which to show
        ///   the AutoComplete window.
        /// </summary>
        /// <param name="autoCompleteMode" type="AutoCompleteMode">
        ///   The AutoCompleteMode intended for the AutoComplete window.
        /// </param>
        /// <returns type="Object">
        ///   - x: The x-coordinate of where the window should be shown.
        ///   - y: The y-coordinate of where the window should be shown.
        /// </returns>
        
        var text = this._textArea.value;
        var selectionStart = this._textArea.selectionStart;
        var selectionEnd = this._textArea.selectionEnd;
        
        // HACK: Firefox's key event doesn't expose any (x, y) coordinates
        // for us to harvest. As a result, we'll need to carry out
        // our own workaround. Both the x- and y-coordinate involve
        // the use of a temporary text area, as you'll see below.
        
        var dummyTextArea = $FX(this._htmlDocument.body).append(
            '<textarea id="namfox-auto-complete-temp-textarea" style="height: 2px;" />'
        ).find("#namfox-auto-complete-temp-textarea");

        var computedStyle = this._htmlDocument.defaultView.getComputedStyle(this._textArea, null);
        dummyTextArea.css({
            fontFamily: computedStyle.fontFamily,
            fontSize: computedStyle.fontSize
        });
        
        // Y-Coordinate: The harder of the two. Grab all of the text that
        // appears before the caret and place it in a 2px-high text area
        // with the same width as the original text area. There are some
        // complications involving the presence of a scrollbar i.e. if
        // the original text area displays a scrollbar, then the dummy
        // text area must also display one, and vice versa.
        
        // Find the index we should use to make our x- and y-coordinate assessments.
        var targetIndex =
            autoCompleteMode === AutoCompleteMode.Default ?
            selectionStart :
            selectionEnd;
            
        dummyTextArea.attr(
            "value",
            text.substr(0, targetIndex)
        );
        
        if (this._textArea.clientHeight !== this._textArea.scrollHeight) {
            // No scroll bar in the original; remove the one from the dummy.
            dummyTextArea.css("overflowY", "hidden");
        }
        
        dummyTextArea.css("width", this._textArea.clientWidth + "px");
        
        var caretOffsetTop = dummyTextArea.attr("scrollHeight");
        
        // X-Coordinate: The easier of the two. Grab the text on this
        // line using the nsISelectionController of the text area's editor.
        // Then place this text in a 2px-wide text area, remove word wrapping,
        // and grab the scroll width.
        
        var selectionController = this._textArea.
            QueryInterface($("nsIDOMNSEditableElement")).
            editor.
            selectionController;
        
        this._textArea.setSelectionRange(targetIndex, targetIndex);
        
        // Move the caret to the beginning of the line.
        selectionController.intraLineMove(false, false);
        
        // Place the current line (up to the caret) inside the text area.
        dummyTextArea.attr(
            "value",
            text.substring(this._textArea.selectionStart, targetIndex)
        );
        dummyTextArea.css("width", "2px");
        
        // Eliminate word wrapping.
        var plainTextEditor = dummyTextArea.
            get(0).
            QueryInterface($("nsIDOMNSEditableElement")).
            editor.
            QueryInterface($("nsIPlaintextEditor"));
        plainTextEditor.wrapWidth = -1;
        
        var caretOffsetLeft = dummyTextArea.attr("scrollWidth");
        
        // Reset the selection
        this._textArea.setSelectionRange(selectionStart, selectionEnd);
        
        $.verbose("AUTOCOMPLETE | Caret offset: (" + caretOffsetLeft + ", " + caretOffsetTop + ")");
        
        // Now we have the offsets from the side of the text area,
        // grab the offsets from the edge of the viewport.
        // This is much easier. :)
        
        var textAreaOffsetLeft = this._textArea.offsetLeft;
        var textAreaOffsetTop = this._textArea.offsetTop;
        
        var offsetParent = this._textArea.offsetParent;
        while (offsetParent && offsetParent.nodeName !== "#document") {
            textAreaOffsetLeft += (offsetParent.offsetLeft - offsetParent.scrollLeft);
            textAreaOffsetTop += (offsetParent.offsetTop - offsetParent.scrollTop);
            offsetParent = offsetParent.offsetParent;
        }
        
        $.verbose("AUTOCOMPLETE | Text Area offset: (" + textAreaOffsetLeft + ", " + textAreaOffsetTop + ")");
        
        // Combine all the information we have to make (x, y) coordinates.
        var offsetLeft = Math.max(
            caretOffsetLeft + textAreaOffsetLeft - this._textArea.scrollLeft,
            textAreaOffsetLeft
        );
        var offsetTop = Math.max(
            caretOffsetTop + textAreaOffsetTop - this._textArea.scrollTop,
            textAreaOffsetTop
        );
        
        $.verbose("AUTOCOMPLETE | Pop-Up Coordinates: (" + offsetLeft + ", " + (offsetTop + 20) + ")");
        
        dummyTextArea.remove();
        
        // Add 20 to the offsetTop because we want it a little further below
        // the caret, not at the caret itself.
        return {
            x: offsetLeft,
            y: offsetTop + 20
        };
    },
    
    _onContextReleased: function() {
        /// <summary>
        ///   Called when the AutoComplete window is hidden.
        /// </summary>
        
        // When the AutoComplete window disappears, our context does as well.
        this._contextStart = null;
        this._lastBestIndex = 0;
        this._surroundWith = null;
        this._textArea = null;
    },

    _onKeyDown: function(event) {
        if (this._autoComplete.isDisplayed && event.keyCode === 27 /* ESC */) {
            this._autoComplete.hide();
            event.preventDefault();
        }
    },
    
    _onKeyPress: function(event) {
        /// <summary>
        ///   Handles the keypress event for any text areas on this page.
        /// </summary>
        /// <param name="event" type="KeyEvent">
        ///   The event object which contains event data.
        /// </param>
        
        var textArea = this._textArea = event.target;
        
        $.verbose("AUTOCOMPLETE | Key Press: " + (event.ctrlKey ? "CTRL " : "") + "KEY " + event.keyCode + " CHAR " + event.charCode);
        
        if (event.ctrlKey && event.charCode === 32) {
            // Ctrl+Space was pressed
            
            event.preventDefault();
            
            if (textArea.selectionStart !== textArea.selectionEnd) {
                this._show(AutoCompleteMode.Surround);
                this._surroundWith = "";
            }
            else {
                var context = this._getContext();
                var bestMatch = this._findBestMatch(context);
                
                if (bestMatch.isOnlyMatch) {
                    this._autoComplete.chooseItem(bestMatch.index);
                    return;
                }
                
                this._show(AutoCompleteMode.Default);
                
                if (bestMatch.itemState === AutoCompleteItemState.Suggest) {
                    this._autoComplete.suggest(bestMatch.index);
                }
                else {
                    this._autoComplete.select(bestMatch.index);
                }
            }
        }
        else if (this._autoComplete.isDisplayed) {
            // If the jQuery AutoComplete plugin is showing for the text area (for @ notifications),
            // then we don't want our AutoComplete to interfere when we're not in "Surround With" mode.
            var autoCompleteResults = $FX("div[className=ac_results]", this._htmlDocument);
            if (this._autoComplete.mode === AutoCompleteMode.Default && autoCompleteResults.get(0) && autoCompleteResults.css("display") !== "none" && autoCompleteResults.find("ul").get(0).childNodes.length !== 0) {
                this._autoComplete.hide();
                return;
            }

            if (event.keyCode === 13 /* Enter */ || event.keyCode === 9 /* Tab */) {
                // If the AutoComplete has an active suggestion, then only tabs
                // can commit the choice. We can also commit the choice if the
                // AutoComplete has an active selection.
                if (
                    this._autoComplete.itemState === AutoCompleteItemState.Select ||
                    this._autoComplete.itemState === AutoCompleteItemState.Suggest &&
                    event.keyCode === 9
                ) {
                    this._autoComplete.chooseItem();
                }
                else {
                    // Pressing enter when a suggestion is active hides the window.
                    this._autoComplete.hide();
                }
                
                event.preventDefault();
            }
            else if (event.keyCode === 38 /* Arrow Down */) {
                this._autoComplete.select(
                    this._autoComplete.itemState === AutoCompleteItemState.Select ?
                    this._autoComplete.selectedIndex - 1 :
                    this._autoComplete.selectedIndex,
                    GestureType.ArrowUp
                );
                event.preventDefault();
            }
            else if (event.keyCode === 40 /* Arrow Down */) {
                this._autoComplete.select(
                    this._autoComplete.itemState === AutoCompleteItemState.Select ?
                    this._autoComplete.selectedIndex + 1 :
                    this._autoComplete.selectedIndex,
                    GestureType.ArrowDown
                );
                event.preventDefault();
            }
            else if (event.keyCode === 33 /* Page Up */) {
                this._autoComplete.pageUp();
                event.preventDefault();
            }
            else if (event.keyCode === 34 /* Page Down */) {
                this._autoComplete.pageDown();
                event.preventDefault();
            }
            else {
                // Hide when the user presses the control key.
                if (event.ctrlKey) {
                    this._autoComplete.hide();
                    return;
                }
                
                var context = null;
                var start = 0;
                var end = this._entries.length - 1;
                var selectedSearchPosition = this._autoComplete.selectedEntry ?
                    this._autoComplete.selectedEntry.searchPosition :
                    0;
                
                if (textArea.selectionStart !== textArea.selectionEnd) {
                    // Handle the surround-with case. Here we don't care about
                    // the "context" in the text area; we just need to place
                    // what the user types into the surround-with text box.
                    
                    if (event.keyCode === 8 /* Backspace */ && this._surroundWith.length > 0) {
                        this._surroundWith = this._surroundWith.substr(0, this._surroundWith.length - 1);
                        end = selectedSearchPosition;
                    }
                    else if (event.charCode) {
                        this._surroundWith += String.fromCharCode(event.charCode);
                        start = selectedSearchPosition;
                    }
                    
                    this._autoComplete.updateSurroundWithText(this._surroundWith);
                    
                    context = {
                        previous: this._surroundWith,
                        following: ""
                    };
                    
                    // The text needs to go into the surround with span element,
                    // not the text area!
                    event.preventDefault();
                }
                else {
                    context = this._getContext();
                    var previousText = context.previous;
                    
                    // If the backspace key is pressed, we want to look backwards,
                    // not forwards, in the list for a better potential match.
                    if (event.keyCode === 8 /* Backspace */) {
                        end = selectedSearchPosition;
                        context.previous = previousText.substr(0, previousText.length - 1);
                    }
                    else {
                        // If we typed a new character, the new match can
                        // only be after where we are now.
                        start = selectedSearchPosition;
                        $.verbose("AUTOCOMPLETE | Fell into this path: " + event.charCode);
                        
                        if (event.charCode) {
                            context.previous += String.fromCharCode(event.charCode);
                        }
                    }
                }
                
                var bestMatch = this._findBestMatch(context, start, end);
                
                if (bestMatch.itemState === AutoCompleteItemState.Suggest) {
                    // If the user typed a space, hide the AutoComplete window
                    // when the best match is a suggestion, because the chances
                    // are that they're done with whatever they typed.
                    if (event.charCode === 32 /* SPACE */) {
                        this._autoComplete.hide();
                    }
                    else {
                        this._autoComplete.suggest(bestMatch.index);
                    }
                }
                else {
                    this._autoComplete.select(bestMatch.index);
                }
            }
        }
        else {
            if (!event.ctrlKey && !event.altKey && !$.getConfig().autoComplete.onDemandOnly) {
                var beginningOfLineMarkupTag = 
                    event.shiftKey &&
                    (
                        event.charCode === 33 /* ! */ ||
                        event.charCode === 35 /* # */ ||
                        event.charCode === 42 /* * */
                    ) ||
                    event.charCode === 61 /* = */;
                    
                var beginningOfWordMarkupTag = event.charCode === 39 /* ' */;
                
                if (
                    beginningOfWordMarkupTag ||
                    beginningOfLineMarkupTag ||
                    !event.shiftKey && event.charCode === 91 /* [ */
                ) {
                    if (beginningOfWordMarkupTag) {
                        // Check that the caret is at the start of a new word.
                        var previous = textArea.value.substring(0, textArea.selectionStart);
                        if (previous.search(/^$|\s$/) === -1) {
                            return;
                        }
                    }
                    
                    if (beginningOfLineMarkupTag) {
                        // Check that the caret is at the start of a new line.
                        var previous = textArea.value.substring(0, textArea.selectionStart);
                        if (previous.search(/^$|\n$/) === -1) {
                            return;
                        }
                    }
                    
                    this._show(AutoCompleteMode.Default);

                    var context = this._getContext(String.fromCharCode(event.charCode));
                    var bestMatch = this._findBestMatch(context);
                    
                    if (bestMatch.itemState === AutoCompleteItemState.Suggest) {
                        this._autoComplete.suggest(bestMatch.index);
                    }
                    else {
                        this._autoComplete.select(bestMatch.index);
                    }
                }
            }
        }
        textArea.focus();
    },
    
    _onKeyUp: function(event) {
        /// <summary>
        ///   Handles the keyup event for any text areas on this page.
        /// </summary>
        /// <param name="event" type="KeyEvent">
        ///   The event object which contains event data.
        /// </param>
        
        if (this._autoComplete.isDisplayed) {
            if (
                event.keyCode === 37 /* Arrow Left */ || event.keyCode === 39 /* Right Arrow */ ||
                event.keyCode === 35 /* END */ || event.keyCode === 36 /* HOME */
            ) {
                this._autoComplete.hide();
            }
        }
    },
    
    _onItemChosen: function(autoCompleteEntry) {
        /// <summary>
        ///   Called when an AutoCompleteEntry is chosen.
        /// </summary>
        /// <param name="autoCompleteEntry" type="AutoCompleteEntry">
        ///   The AutoCompleteEntry that was chosen.
        /// </param>
        
        var context = this._getContext();
        
        autoCompleteEntry.addTo(this._textArea, context);
    },
    
    _onSelectedIndexChanged: function() {
        /// <summary>
        ///   Called when the selected index of AutoComplete changes.
        ///   Here we ensure that the text area keeps focus even
        ///   when a user clicks an AutoCompleteEntry.
        /// </summary>
        
        this._textArea.focus();
    },
    
    _show: function(autoCompleteMode) {
        /// <summary>
        ///   A helper function to show the AutoComplete window.
        /// </summary>
        
        this._autoComplete.show(
            this._getPopupPoint(autoCompleteMode),
            autoCompleteMode
        );
    },
    
    attach: function() {
        /// <summary>
        ///   Attaches AutoComplete to all text areas on the page.
        /// </summary>
        
        // If AutoComplete is not enabled or it already exists, stop.
        if (!$.getConfig().autoComplete.enabled ||
            $FX("#namfox-auto-complete", this._htmlDocument).length === 1) {
            return;
        }
        
        this._autoComplete = new AutoComplete(this._entries, this._htmlDocument);
        this._autoComplete.contextReleased = $.createCallback(this._onContextReleased, this);
        this._autoComplete.itemChosen = $.createCallback(this._onItemChosen, this);
        this._autoComplete.selectedIndexChanged = $.createCallback(this._onSelectedIndexChanged, this);
        
        this._autoComplete.render();
        
        $FX("head", this._htmlDocument).append(
            '<link id="namfox-auto-complete-css" href="chrome://namfox/skin/auto-complete.css" rel="stylesheet" type="text/css" />'
        );

        var textAreas = $FX("textarea", this._htmlDocument);
        
        for (var i = 0; i < textAreas.length; ++i) {
            this.attachSingle(textAreas.get(i));
        }
        
        var that = this;
        // Hide the AutoComplete window if we click outside of the window.
        $FX(this._htmlDocument.body).click(
            function(event) {
                that._autoComplete.hide();
            }
        );
    },
    
    attachSingle: function(textArea) {
        /// <summary>
        ///   Attaches AutoComplete to a single text area.
        ///   Useful when the text area is created dynamically.
        /// </summary>
        /// <param name="textArea" type="TextArea">
        ///   The text area to which to attach AutoComplete.
        /// </param>
        
        if (!textArea || textArea.length === 0 || !$.getConfig().autoComplete.enabled) {
            return;
        }
        
        if (!textArea.get) {
            textArea = $FX(textArea);
        }
        
        $.assert(this._autoComplete, "The AutoComplete window must exist before adding a text area.");
        
        textArea.
            keydown($.createCallback(this._onKeyDown, this)).
            keypress($.createCallback(this._onKeyPress, this)).
            keyup($.createCallback(this._onKeyUp, this));
    }
};

}});